第三章 STM32的GPIO输入输出功能
STM32作为单片机裸机(不带操作系统)开发,其实和51单片机属于大同小异。本章节集中将STM32单片机控制小灯、蜂鸣器、继电器以及读取外部按键集中实验验证。
3.1点亮所有LED小灯
双击工程文件夹中的CubeMX图标文件,即可打开点亮小灯的工程,如图3-1所示。

找到PD13、PD14和PD15这三个引脚,鼠标单击引脚,依次分别选择GPIO_Output模式,如图3-2所示。

在软件左侧列表,找到System Core下的GPIO配置界面,将默认电平设置为高电平,输出模式为推挽输出,继续给GPIO设置用户名称,如图3-3所示。

单击软件右上角的GENERATE CODE按钮,更新代码,如图3-4所示。

双击打开MDK工程文件,在程序代码中添加LED1、LED2、LED3和LED4点亮程序,如下所示。
/* USER CODE BEGIN WHILE */
HAL_GPIO_WritePin(GPIOD,LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin,
GPIO_PIN_RESET);//打开LED1-4
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
学过点亮1个小灯后,点亮4个小灯就不难理解了。这行代码依然是调用HAL库的写法,即将GPIOD的对应的4个引脚,全部设置为低电平。代码编写完成后编译程序,单击Keil软件的下载按钮,将新程序下载进Kingst32开发板中。如果出现错误提示,需要重新配置下载器,如图3-5所示。

按下Kingst32开发板的复位按键,可以观察到,LED1到LED4全部都亮起来了。
和第一章点亮LED小灯类似,只要在CubeMX中对GPIO口起了别名,在对应的/*Private defines区域,CubeMX自动为用户生成宏定义,我将开发板的蜂鸣器,继电器的引脚全部配置好以后,生成的宏定义区域如图3-6所示。

3.2流水灯实验
各位读者是否还记得在51单片机里流水灯的程序是如何写的?
P0 = ~(0x01 << cnt);
在51单片机中一个P口有8个IO口,比如P0口对应的IO口是是P0.0到P0.7。而在STM32F103中一个GPIO口有16个IO口,使用时需要先指定用哪个GPIO比如GPIOD,然后选择GPIO口对应的IO口是GPIO_PIN_0到GPIO_PIN_15,如果选择所有的GPIO则用GPIO_PIN_ALL。 在图3-5中的宏定义的程序代码中,在GPIO_PIN_12的位置,单击鼠标右键,选择“Go To Definition of‘GPIO_PIN_12’”,会找到GPIOD_PIN_0到GPIOD_PIN_15的宏定义语句,如图3-6所示和图3-7所示。


从图3-7可以看出,对于HAL库的宏定义,一组GPIO有16个引脚,对应了两字节的16个位,选择哪个引脚,就是对应的哪一位是1,其余位为0。现在再看点亮4个小灯的程序语句。
HAL_GPIO_WritePin(GPIOD,LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin,GPIO_PIN_RESET);
对于所使用的HAL库函数给引脚写入数据操作,实参GPIOD代表传递的是GPIOD,实参LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin代表选择这4个引脚,即0xF000,选中了最高的4个引脚,实参GPIO_PIN_RESET代表将对应引脚输出低电平。
编写流水灯的程序如下所示:
int main(void)
{
uint16_t LED_LeftMove_Pin = LED1_Pin;//控制LED左移变量
HAL_GPIO_WritePin(GPIOD,LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin, GPIO_PIN_SET);//关闭全部
while (1)
{
for(uint8_t i =0;i<4;i++)
{
HAL_GPIO_WritePin(GPIOD,LED_LeftMove_Pin<<i,GPIO_PIN_RESET);
//点亮
HAL_Delay(1000);
//延时1s
HAL_GPIO_WritePin(GPIOD,LED_LeftMove_Pin<<i,GPIO_PIN_SET);
//关闭
}
}
}
}
关于这段流水灯代码,有以下两点需要说明:
- Kingst32开发板上有4个小灯,从PD12到PD15控制,因此流水灯的时候从GPIOD_PIN_12开始到GPIOD_PIN_15结束。
- C语言中,C89标准下变量定义必须写在所有的执行程序之前,但是C99标准允许什么时候使用随时定义变量。比如程序中uint8_t i=0就是在使用时定义的。
- HAL_Delay()是HAL提供的一个精确延时函数,传入的参数是延时时长,单位是ms,最大延时时长为0xFFFFFFFFms,它是通过内核定时器Systick产生的定时器中断实现的,定时精确度比较高。但是和前边51单片机delay延时类似,这个函数是阻塞的,通过等待浪费内核资源的方式达到延时的目的,只是作为演示使用。
3.3蜂鸣器和继电器
3.3.1蜂鸣器
蜂鸣器按照驱动方式分为有源蜂鸣器和无源蜂鸣器。这里的有源和无源不是指电源,而是振荡源。有源蜂鸣器内部带了振荡源,如图3-8所示中,给了BEEP引脚一个低电平,三极管导通蜂鸣器就会直接响。而无源蜂鸣器必须给500Hz~4.5KHz之间的脉冲频率信号来驱动它才会响。
蜂鸣器经常用于电脑、打印机、万用表这些设备上做提示音使用。蜂鸣器的工作电流较大,需要使用三极管驱动。
STM32上电后复位IO口是高阻态,因此电压高低取决于外部电路。增加一个R17的下拉电阻确保上电后IO口被拉成低电平,三极管Q2不会导通。

3.3.2继电器
继电器是根据一定的信号来接通或者断开电流电路的控制元件,它具有控制系统和被控制系统。当控制系统达到一定条件时,继电器会动作,使被控制的输出电路导通或者断开。
Kingst32开发板所使用的继电器是一个5V控制系统,最大被控制电压250V的继电器。它一共有5个引脚,其中2个控制系统引脚,3个被控制系统引脚。3个被控制引脚为单刀双掷,分别为公共端,常开和常闭,如图3-9所示。

图3-9中,当单片机的ERELAY引脚为低电平时,三极管截止,继电器的控制端没有电流通过,4脚和公共端3脚接到一起;当单片机的ERELAY引脚为高电平时,三极管导通,这时候由于磁力的作用,5脚和公共端3脚吸合到一起。 继电器的用法分为常开(NO,Normally Open)和常闭(NC,Normally Closed)两种方式。常开指的是继电器线圈在未通电的状态下,其触点处于断开状态。常开方式常用于在特定条件下启动某个电器设备的场景,比如声控灯,电动门禁等。常闭指的是继电器线圈在未通电的状态下,其触点处于闭合状态。常闭常用于在特定条件下断开连接的场合,比如安全监测系统,当检测到气体泄漏时,继电器激活,断开常闭点。
3.3.3蜂鸣器和继电器程序代码编写
继续对CubeMX工程配置,找到软件中的芯片引脚,将Kingst32开发板原理图上控制蜂鸣器和继电器的PB1和PE7设置为GPIO_Output模式,如图3-10所示。

在软件左侧列表中的System Core下的GPIO配置页面,点击打开,给PB1和PE7添加用户名称,分别为BEEP和ERELAY,再将GPIO output level默认值设置成“Low”。在原有流水灯程序基础上,让流水灯流动一次,继电器吸合1秒,蜂鸣器响0.1秒。
int main(void)
{
uint16_t LED_LeftMove_Pin = LED1_Pin;//控制LED左移变量
/**省略其他**/
HAL_GPIO_WritePin(GPIOD,LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin, GPIO_PIN_SET);//关闭全部
while (1)
{
for(uint8_t i =0;i<4;i++)
{
HAL_GPIO_WritePin(GPIOD,LED_LeftMove_Pin<<i,GPIO_PIN_RESET);
//点亮
HAL_Delay(1000);//延时1s
HAL_GPIO_WritePin(GPIOD,LED_LeftMove_Pin<<i,GPIO_PIN_SET);
//关闭
}
HAL_GPIO_WritePin(BUZZ_GPIO_Port, BUZZ_Pin, GPIO_PIN_SET);
//打开蜂鸣器
HAL_Delay(100);
HAL_GPIO_WritePin(BUZZ_GPIO_Port, BUZZ_Pin, GPIO_PIN_RESET);
//关闭蜂鸣器
HAL_GPIO_WritePin(ERELAY_GPIO_Port, ERELAY_Pin, GPIO_PIN_SET);
//打开继电器
HAL_Delay(900);
HAL_GPIO_WritePin(ERELAY_GPIO_Port, ERELAY_Pin, GPIO_PIN_RESET);
//关闭继电器
}
}
细心的用户会发现,只要在CubeMX给引脚按照用户喜欢命名XXX后,CubeMX就会在头文件中进行宏定义,XXX_GPIO_Port代表了整个GPIOX,而XXX_Pin代表了GPIOX的某一个引脚,方便用户根据自己习惯命名编程操作。
3.4多个端口引脚控制的流水灯
3.4.1流水灯原理
在实际产品应用开发的时候,有时候受限于特定功能引脚分配或者PCB走线的约束,功能单元关联的多个GPIO口有可能离散分布于不同的Bank。比如在Kingst32开发板上有8个LED小灯,想要将其组成一个流水灯,而LED5到LED8并不和LED1到LED4接在同一组GPIO组下,并且LED5到LED8本身也是分散的,如图3-10所示。这个时候想要编写代码实现流水灯程序,该怎么办呢?

首先根据最原始的方式写一个流水灯程序,如下所示。
int main(void)
{
uint16_t LED1_LeftMove_Pin = LED1_Pin;//控制LED左移变量
uint16_t LED5_LeftMove_Pin = LED5_Pin;//控制LED左移变量
/**省略其他**/
HAL_GPIO_WritePin(GPIOD,LED1_Pin|LED2_Pin|LED3_Pin|LED4_Pin,
GPIO_PIN_SET);//关闭全部
while (1)
{
for(uint8_t i =0;i<4;i++)
{
HAL_GPIO_WritePin(GPIOD,LED1_LeftMove_Pin<<i,GPIO_PIN_RESET);
//点亮
HAL_Delay(1000);//延时1s
HAL_GPIO_WritePin(GPIOD,LED1_LeftMove_Pin<<i,GPIO_PIN_SET);
//关闭
}
for(uint8_t i =0;i<3;i++)
{
HAL_GPIO_WritePin(GPIOD,LED5_LeftMove_PinLED5_LeftMove_Pin<<2*i,GPIO_PIN_RESET);//点亮
HAL_Delay(1000);//延时1s
HAL_GPIO_WritePin(GPIOD,LED5_LeftMove_Pin<<2*i,GPIO_PIN_SET); //关闭
}
HAL_GPIO_WritePin(GPIOD,LED8_Pin,GPIO_PIN_RESET);//关闭
HAL_Delay(1000);
HAL_GPIO_WritePin(GPIOD,LED8_Pin,GPIO_PIN_SET);//关闭
HAL_GPIO_WritePin(BUZZ_GPIO_Port, BUZZ_Pin, GPIO_PIN_SET);
//打开蜂鸣器
HAL_Delay(100);
HAL_GPIO_WritePin(BUZZ_GPIO_Port, BUZZ_Pin, GPIO_PIN_RESET);
//关闭蜂鸣器
HAL_GPIO_WritePin(ERELAY_GPIO_Port, ERELAY_Pin, GPIO_PIN_SET);
//打开继电器
HAL_Delay(900);
HAL_GPIO_WritePin(ERELAY_GPIO_Port, ERELAY_Pin, GPIO_PIN_RESET);
//关闭继电器
}
}
将这个程序编译完成后下载进单片机就可以看到流水灯、蜂鸣器和继电器工作状态。
3.4.2流水灯代码优化
流水灯功能虽然实现了,但是代码显得不够优雅。利用结构体来尝试一个新的办法。
按下快捷键CTRL+N创建一个新的头文件,将其命名为LEDBUZ.h,保存在Core/Inc目录下,如图3-11所示。

在头文件中的代码如下所示。
#ifndef __LEDBUZ_H
#define __LEDBUZ_H
#include "main.h"
#define CODE_STEP 10
typedef struct {
GPIO_TypeDef *GPIOx; //GPIO指针
uint16_t GPIO_Pin; //引脚
GPIO_PinState PinStateOpen; //引脚状态
uint32_t Delay_ms; //延时时长
}IO_Control;
void LED_Running(void);
#endif
在LEDBUZ.h头文件中构建一个结构体,第一个元素是一个指针变量,这个指针指向的元素是结构体。在GPIO_TypeDef位置点右键,选择“Go To Definition of‘GPIO_TypeDef’”,可以看到GPIO_TypeDef的结构封装在stm32f103xe.h这个头文件中,这个头文件包含了芯片的所有寄存器的定义、内存映射和基本配置。其中GPIO_TypeDef的结构成员为涉及GPIO配置相关的寄存器,如图3-12所示。

对于C语言基础比较好的用户来说这个结构体指针不难理解,对于C语言基础薄弱的用户,稍加阐述以便理解。
结构体类型除了标准的定义方式外,还可以用typedef来改写,如下所示。 结构体标准方式:
Struct GPIO_TypeDef
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
};
Struct GPIO_TypeDef GPIO1, GPIO2, GPIO3; //定义该结构体类型的变量
Struct GPIO_TypeDef *GPIOX; //定义该结构体类型的指针
结构体利用typedef改写方式(更加常用):
typedef struct GPIO_TypeDef(结构标签推荐省略)
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
} GPIO_TypeDef;
Struct GPIO_TypeDef GPIO1, GPIO2, GPIO3; //定义该结构体类型的变量
Struct GPIO_TypeDef *GPIOX; //定义该结构体类型的指针
通过查看GPIO_PinState可以得知这是个枚举体,这个枚举体一共有两个成员,一个是GPIO_PIN_RESET(0),另外一个是GPIO_PIN_SET(1),对应的就是单片机IO口的低电平和高电平。
现在再来分析IO_Control这个结构体类型成员,第一个成员是一个结构体指针,这个结构体指针指向了某一组GPIO口;第二个成员是一个uint16类型的变量,用来确定是这一组GPIO口的第几个引脚;第三个成员是一个枚举体,用来确定引脚状态;第四个成员是一个uint32类型的变量,用来确定延时时间的。
在IO_Control这个结构类型中,存储着HAL_GPIO_WritePin函数传递的参数类型和HAL_Delay函数传递参数的类型。然后使用这个结构体类型,创建了一个const(只读)的数组保存流水灯程序的每一步动作和延时时间,保存到单片机Flash中,实现更加优雅的流水灯代码。
新建一个LEDBUZ .c文件,保存到Core/Inc目录下,将流水灯主要的程序代码都写到这个文件中,在main.c文件调用一下相关函数,程序如下所示。
#include "LEDBUZ.h"
const IO_Control My_Gpio[CODE_STEP]={
{GPIOD, LED1_Pin,GPIO_PIN_RESET, 1000},
{GPIOD, LED2_Pin,GPIO_PIN_RESET, 1000},
{GPIOD, LED3_Pin,GPIO_PIN_RESET, 1000},
{GPIOD, LED4_Pin,GPIO_PIN_RESET, 1000},
{GPIOE, LED5_Pin,GPIO_PIN_RESET, 1000},
{GPIOE, LED6_Pin,GPIO_PIN_RESET, 1000},
{GPIOE, LED7_Pin,GPIO_PIN_RESET, 1000},
{GPIOE, LED8_Pin,GPIO_PIN_RESET, 1000},
{BUZZ_GPIO_Port, BUZZ_Pin,GPIO_PIN_SET, 100},
{ERELAY_GPIO_Port, ERELAY_Pin,GPIO_PIN_SET, 900},
};
void LED_Running()
{
for(uint8_t i =0;i<CODE_STEP;i++)
{//程序步
HAL_GPIO_WritePin(My_Gpio[i].GPIOx,My_Gpio[i].GPIO_Pin, My_Gpio[i].PinStateOpen);//点亮
HAL_Delay(My_Gpio[i].Delay_ms);//延时1s
HAL_GPIO_TogglePin(My_Gpio[i].GPIOx,My_Gpio[i].GPIO_Pin);//反转电平
}
}
函数HAL_GPIO_TogglePin是HAL库提供的对电平翻转的函数,即对某个IO口引脚取反操作,传递的参数比较函数HAL_GPIO_WritePin少了对IO口电平状态赋值。
在这段程序代码中,利用for循环语句控制程序步,通过My_Gpio数组来读取相关的数据和执行相关的操作。并且可以通过改变CODE_STEP来实现数组扩充,实现不同程序的执行效果。特别的,这种方式不受硬件IO口连接的约束。即使是序列化被拆散,IO口被分布在不同的引脚组内,利用这种方式可以对其重新排列,使得程序的执行更顺畅。
在Keil软件的左侧,找到Application/User/Core分组,直接双击它,找到LEDBUZ .c所在的位置,将其加入进去。在main.c程序代码中,添加#include "LEDBUZ.h" 语句。在main.c文件中的main主函数while(1)主循环内部,就可以直接调用LED_Running()函数了。
各位读者将程序代码完成后,编译后下载到单片机中运行一下可以看到Kingst32开发板上的代码优化后的流水灯效果。
3.5矩阵按键
3.5.1矩阵按键读取
在Kingst32开发板上,设计了一个4行4列矩阵键盘电路,根据需求连接了15个按键。在设计硬件电路时,由于STM32的引脚可以配置为内部上拉电阻,因此外部的上拉电阻可以节省掉,如图3-13所示。

打开CubeMX软件,在图形化界面的芯片引脚图中,依次将PD4到PD7引脚设置成输入模式,将PE3到PE6配置成输出模式,如图3-14所示。

在软件左侧的System Core下的GPIO配置界面,将PD4到PD7设置为上拉电阻,并且自定义命名为KeyW1、KeyW2、KeyW3和KeyW4,如图3-14所示。

将PE3到PE6引脚逐个修改为默认输出电平为High,最大输出速度为High,用户的名称改成KeyC1到KeyC4,如图3-15所示。

通过程序轮流的将KeyC1到KeyC4拉低,读取KeyW1到KeyW4引脚电平的方式,就能够读取所有按键是否被按下,简易的演示代码如下(未消抖)。
/***
函数名:按键扫描驱动程序
功能:扫描按键是否按下
传参:无
返回值类型:uint8_t
返回值意义:按键键值1-16 代表按键按键
**/
uint8_t keyScan()
{
uint8_t KeyNum;
uint8_t i,j;
for(i=0;i<4;i++)
{
HAL_GPIO_WritePin(KeyC1_GPIO_Port, KeyC1_Pin<<i, GPIO_PIN_RESET);
__NOP();
__NOP();
__NOP();//延时等待引脚电平稳定
for(j=0;j<4;j++)
{
if(HAL_GPIO_ReadPin(KeyW1_GPIO_Port,KeyW1_Pin<<j)==GPIO_PIN_RESET) {
KeyNum=j+1+4*i;
}
}
HAL_GPIO_WritePin(KeyC1_GPIO_Port, KeyC1_Pin<<i, GPIO_PIN_SET);
}
return KeyNum;
}
HAL_GPIO_ReadPin为读取外部某一个引脚的HAL库函数,返回值是GPIO_PinState的枚举类型。库函数在STM32用户手册中有逐一介绍,各位读者不需要专门去学习,只需要跟随本教材逐步了解常用的,待到熟练常用的库函数后可针对性了解手册中其他部分。
3.5.2矩阵按键映射
对照keyScan函数以及Kingst32原理图可以发现,按键的数值KeyNum与开发板的按键功能不是一一对应的,比如KeyNum等于2时,对应开发板上的是‘7’这个数字按键。在《手把手教你学51单片机 C语言版》教材学习按键的章节,将按键的键值统一映射成为标准的键码,方便统一标准,维护以及和他人协同工作,这是一种方法和思路。
有的时候并非是超大型项目,或者不必要统一映射为标准的键码,还可以根据实际硬件的需求来做键码映射。这次在Kingst32开发板上的矩阵按键,采用枚举体将键盘的键值更加直白的表达出来,代码如下所示。
Key.h头文件代码:
#ifndef __KEY_H
#define __KEY_H
#include "main.h"
typedef enum {
KEY_NONE = 0,
KET_7=2,
KET_8,
KET_9,
KET_0,
KET_4,
KET_5,
KET_6,
KET_Down,
KET_1,
KET_2,
KET_3,
KET_Left,
KET_Ok,
KET_Right,
KET_Up,
}Key_Enum;
Key_Enum keyScan(void);
void keyAction(Key_Enum key);
#endif
Key.c代码:
#include "Key.h"
/**
函数名:按键扫描驱动程序
功能:扫描按键是否按下
传参:无
返回值类型:Key_Enum
返回值意义:按键键值1-16 代表按键按键
*/
Key_Enum keyScan()
{
Key_Enum KeyNum;
uint8_t i,j;
for(i=0;i<4;i++){
HAL_GPIO_WritePin(KeyC1_GPIO_Port, KeyC1_Pin<<i, GPIO_PIN_RESET);
__NOP();
__NOP();
__NOP();//延时等待引脚电平稳定
for(j=0;j<4;j++){
if(HAL_GPIO_ReadPin(KeyW1_GPIO_Port,KeyW1_Pin<<j)==GPIO_PIN_RESET){
KeyNum =(Key_Enum)(j+1+4*i);//显式转换成枚举类型
}
}
HAL_GPIO_WritePin(KeyC1_GPIO_Port, KeyC1_Pin<<i, GPIO_PIN_SET);
}
return KeyNum;
}
/***
函数名:按键执行程序
功能:根据键值执行代码
传参:Key_Enum 按键键值
返回值类型:
返回值意义:
**/
void keyAction(Key_Enum key)
{
switch (key){
case KEY_NONE: break;
case KET_0: break;
case KET_1:
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_SET);//关闭LED1
break;
case KET_2:
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_RESET);//点亮LED1
break;
case KET_3:break;
case KET_4:break;
case KET_5:break;
case KET_6:break;
case KET_7:break;
case KET_8:break;
case KET_9: break;
case KET_Up:break;
case KET_Down:break;
case KET_Left: break;
case KET_Ok: break;
case KET_Right: break;
default: break;
}
}
将keyAction函数放到main.c文件main主函数的while(1)主循环中,就可以实现按键按下点亮和熄灭LED1的功能。
3.5.3课后作业
矩阵按键实现花样流水灯。